Skip to content

runtime: deliver os/signal notifications under the threads scheduler - #5631

Open
yohimik wants to merge 1 commit into
tinygo-org:devfrom
yohimik:upstream-pr/signal-threads
Open

runtime: deliver os/signal notifications under the threads scheduler#5631
yohimik wants to merge 1 commit into
tinygo-org:devfrom
yohimik:upstream-pr/signal-threads

Conversation

@yohimik

@yohimik yohimik commented Aug 30, 2026

Copy link
Copy Markdown

runtime: deliver os/signal notifications under the threads scheduler

What this does

signal.Notify delivers a signal on hosted linux and macOS only while some
other goroutine is in time.Sleep. Both hosts default to -scheduler=threads.
A program that installs a handler and then waits on the channel, which is what a
command does to stop on SIGINT, waits for ever.

The receiving goroutine in os/signal calls signal_recv, which parks itself
with task.Pause when nothing is pending. Only checkSignals resumes it, and
on the receive path the callers of checkSignals are waitForEvents, the idle
hook of the cooperative scheduler, and sleepTicks. With threads there is no
scheduler loop, so waitForEvents never runs and only a sleep elsewhere in the
program lets a signal through. This is why the current testdata/signal.go
passes. It sleeps for 100 ms in main.

signal_recv now blocks on a futex of its own that the handler wakes, with the
same 0/1 protocol that the handler already uses on signalFutex. It cannot
share signalFutex, because sleepTicks waits on that one too and swaps it
back to zero, so a time.Sleep anywhere in the program would take the wakeup of
the receiver. The handler only gets an atomic store and a futex wake syscall,
which are both safe in a signal handler on an arbitrary thread. The
stop-the-world signal of the GC uses a different handler that this does not
touch.

signalWaitUntilIdle, which signal.Stop and signal.Reset call before they
return, had the same problem from the other side. It spun on Gosched, which is
a no-op with threads, so it used a core until the receiver emptied the last
signal. It now waits on a futex that signal_recv wakes.

The two versions live in src/runtime/signal_cooperative.go and
src/runtime/signal_threads.go, split on scheduler.threads like the
schedulers themselves.

Evidence

testdata/signal.go gets a first phase that waits on the channel and nothing
else, so no timer and no sleep can carry the delivery. It is already in the
TestBuild host list, so it runs in the linux and the macOS CI jobs with no
test-list change.

Built on macOS 26.6 arm64, default scheduler.

Build Result
current dev no output, still running after 30s, killed
with this change got expected signal / got expected signal / exiting signal program, exit 0

A downstream product also ships binaries built with a fork that carries this
change, in the published release dispat v1.4.0.
https://github.com/yohimik/dispat/releases/tag/services%2Fdispat%2Fv1.4.0

Scope and known gaps

  • The cooperative half keeps the current code. It moves file, and nothing else.
  • On a hosted POSIX host the cooperative half cannot be reached today.
    -scheduler=tasks fails to link on darwin with duplicate symbol: _tinygo_task_exit, and -scheduler=asyncify fails with undefined: task.SystemStack. Both failures also happen on the current dev branch with
    testdata/print.go, so they are separate and are not addressed here.
  • os/signal.signal_ignored is still missing, so tinygo test os/signal does
    not link. That is why the test is a testdata program and not the standard
    library suite. It can be a follow-up.
  • No change for wasm, wasip1, wasip2, windows or baremetal.

Related

This is what a command line program needs for an orderly stop on SIGINT or
SIGTERM. It is one of the pieces that made a real CLI work under TinyGo on
hosted linux and macOS.

Related pull requests

This change is part of one body of work. Together the changes make programs that use the network and child processes work on hosted linux and macOS. A full CLI was tested end to end with all of them and ships binaries built this way, see dispat v1.4.0 in the evidence section.

In this repository

In tinygo-org/net

A merge order that works. The three bug fixes are independent. #5633 goes before #5635. HTTPS on linux needs only #5633 and #5635. Full darwin support also needs #5636, the net changes and a new src/net submodule pin.

signal.Notify delivers a signal on hosted linux and macOS only while some other
goroutine is in time.Sleep. Both hosts default to -scheduler=threads. A program
that installs a handler and then waits on the channel, which is what a command
does to stop on SIGINT, waits for ever.

The receiving goroutine in os/signal calls signal_recv, which parks itself with
task.Pause when nothing is pending. Only checkSignals resumes it, and on the
receive path the callers of checkSignals are waitForEvents, the idle hook of
the cooperative scheduler, and sleepTicks. With threads there is no scheduler
loop, so waitForEvents never runs and only a sleep elsewhere in the program
lets a signal through.

Give the receiver a wait that works on a thread. signal_recv now blocks on a
futex of its own that the handler wakes, with the same 0/1 protocol that the
handler already uses on signalFutex. It cannot share signalFutex, because
sleepTicks waits on that one too and swaps it back to zero, so a time.Sleep
anywhere in the program would take the wakeup of the receiver. The handler only
gets an atomic store and a futex wake syscall, which are both safe in a signal
handler on an arbitrary thread. The stop-the-world signal of the GC uses a
different handler that this does not touch.

signalWaitUntilIdle, which signal.Stop and signal.Reset call before they
return, had the same problem from the other side. It spun on Gosched, which is
a no-op with threads, so it used a core until the receiver emptied the last
signal. It now waits on a futex that signal_recv wakes.

The cooperative path keeps its behaviour. The two versions live in
signal_cooperative.go and signal_threads.go, split on scheduler.threads like
the schedulers themselves.

testdata/signal.go grows a first phase that waits on the channel and nothing
else. Built from the current dev branch on macOS 26.6 arm64 it prints nothing
and hangs. With this change it prints the expected output and exits.
@yohimik
yohimik force-pushed the upstream-pr/signal-threads branch from 545f911 to 369c493 Compare September 2, 2026 08:50
@yohimik

yohimik commented Sep 2, 2026

Copy link
Copy Markdown
Author

Rebased on dev after the 0.42.0 release. The problem is present in v0.42.0 as
released. testdata/signal.go from this branch, built with the official
v0.42.0 tarballs, prints nothing and still runs after 30 seconds on
darwin/arm64 and on linux/arm64. Both report scheduler: threads as the
default.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant